web/src/routes/image/[id]/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount } from 'svelte';
3 import { getImage, addTags, removeTags, suggestTags, imageDownloadUrl, type ImageDetail } from '$lib/api';
4
5 let { params } = $props();
6 let id = $derived(parseInt(params.id));
7
8 let img = $state<ImageDetail | null>(null);
9 let loading = $state(true);
10 let error = $state('');
11
12 let tagInput = $state('');
13 let suggestions = $state<string[]>([]);
14 let showSuggestions = $state(false);
15 let sugIndex = $state(-1);
16 let tagError = $state('');
17
18 onMount(async () => {
19 try {
20 img = await getImage(id);
21 } catch (err) {
22 error = err instanceof Error ? err.message : 'Failed to load image';
23 } finally {
24 loading = false;
25 }
26 });
27
28 function formatSize(bytes: number): string {
29 if (bytes < 1024) return bytes + ' B';
30 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
31 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
32 }
33
34 function formatDate(s: string): string {
35 return new Date(s).toLocaleString();
36 }
37
38 async function onTagInput() {
39 sugIndex = -1;
40 const q = tagInput.split(',').pop()?.trim() || '';
41 if (q.length < 1) {
42 suggestions = [];
43 showSuggestions = false;
44 return;
45 }
46 try {
47 const result = await suggestTags(q);
48 suggestions = result.map((t) => t.tag);
49 showSuggestions = suggestions.length > 0;
50 } catch {
51 suggestions = [];
52 showSuggestions = false;
53 }
54 }
55
56 function selectSuggestion(s: string) {
57 const parts = tagInput.split(',').slice(0, -1);
58 parts.push(s);
59 tagInput = parts.join(', ') + ', ';
60 showSuggestions = false;
61 sugIndex = -1;
62 }
63
64 async function handleAddTags() {
65 if (!img) return;
66 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
67 if (tags.length === 0) return;
68 tagError = '';
69 try {
70 const result = await addTags(img.id, tags);
71 img.tags_detail = result.tags_detail;
72 img.tags = result.tags_detail.map((t) => t.tag);
73 tagInput = '';
74 } catch (err) {
75 tagError = err instanceof Error ? err.message : 'Failed to add tags';
76 }
77 }
78
79 async function handleRemoveTags() {
80 if (!img) return;
81 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
82 if (tags.length === 0) return;
83 tagError = '';
84 try {
85 const result = await removeTags(img.id, tags);
86 img.tags_detail = result.tags_detail;
87 img.tags = result.tags_detail.map((t) => t.tag);
88 tagInput = '';
89 } catch (err) {
90 tagError = err instanceof Error ? err.message : 'Failed to remove tags';
91 }
92 }
93
94 // Auto-scroll active suggestion into view.
95 $effect(() => {
96 if (sugIndex >= 0) {
97 const el = document.querySelector('.sug-btn.active') as HTMLElement | null;
98 if (el) el.scrollIntoView({ block: 'nearest' });
99 }
100 });
101</script>
102
103{#if loading}
104 <p class="state-msg">Loading...</p>
105{:else if error}
106 <p class="state-msg error">{error}</p>
107{:else if img}
108 <div class="page-head">
109 <h1>{img.filename}</h1>
110 <div class="head-line"></div>
111 </div>
112
113 <div class="layout">
114 <div class="preview">
115 {#if img.thumbnail_path}
116 <img src={imageDownloadUrl(img.id)} alt={img.filename} />
117 {:else}
118 <div class="no-thumb">NO THUMBNAIL</div>
119 {/if}
120 </div>
121
122 <div class="meta">
123 <div class="meta-block uploader-block">
124 <span class="meta-label">UPLOADED BY</span>
125 <div class="uploader-row">
126 {#if img.avatar_url}
127 <img src={img.avatar_url} alt="" class="avatar-img" />
128 {/if}
129 <a href="/browse?uploader={encodeURIComponent(img.uploader_username)}" class="meta-value uploader-link">{img.uploader_username}</a>
130 </div>
131 </div>
132 <div class="meta-block">
133 <span class="meta-label">ID</span>
134 <span class="meta-value">#{img.id}</span>
135 </div>
136 <div class="meta-block">
137 <span class="meta-label">DIMENSIONS</span>
138 <span class="meta-value">{img.width} × {img.height}</span>
139 </div>
140 <div class="meta-block">
141 <span class="meta-label">FILE SIZE</span>
142 <span class="meta-value">{formatSize(img.file_size)}</span>
143 </div>
144 <div class="meta-block">
145 <span class="meta-label">TYPE</span>
146 <span class="meta-value">{img.mime_type}</span>
147 </div>
148 <div class="meta-block">
149 <span class="meta-label">UPLOADED</span>
150 <span class="meta-value">{formatDate(img.created_at)}</span>
151 </div>
152 <div class="meta-block tags-block">
153 <span class="meta-label">TAGS</span>
154 <div class="tag-pills">
155 {#if img.tags_detail.length > 0 || (img.innate_tags && img.innate_tags.length > 0)}
156 {#each img.tags_detail as t (t.tag)}
157 <a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
158 <span class="tag-name">{t.tag}</span>
159 <span class="tag-count">{t.usage_count}</span>
160 </a>
161 {/each}
162 {#if img.innate_tags}
163 {#each img.innate_tags as t (t)}
164 <a href="/browse?tag={encodeURIComponent(t)}" class="tag-pill innate">
165 <span class="tag-name">{t}</span>
166 </a>
167 {/each}
168 {/if}
169 {:else}
170 <span class="none">none</span>
171 {/if}
172 </div>
173 </div>
174
175 {#if img.is_owner}
176 <div class="meta-block edit-tags-block">
177 <span class="meta-label">EDIT TAGS</span>
178 <div class="tag-input-row">
179 <div class="tag-wrapper">
180 <input type="text" placeholder="funny, meme, cat" bind:value={tagInput}
181 oninput={onTagInput}
182 onkeydown={(e) => {
183 if (e.key === 'Enter') {
184 if (sugIndex >= 0 && sugIndex < suggestions.length) {
185 e.preventDefault();
186 selectSuggestion(suggestions[sugIndex]);
187 }
188 } else if (e.key === 'Tab' || e.key === 'ArrowDown') {
189 if (showSuggestions && suggestions.length > 0) {
190 e.preventDefault();
191 sugIndex = (sugIndex + 1) % suggestions.length;
192 }
193 } else if (e.key === 'ArrowUp') {
194 if (showSuggestions && suggestions.length > 0) {
195 e.preventDefault();
196 sugIndex = sugIndex <= 0 ? suggestions.length - 1 : sugIndex - 1;
197 }
198 } else if (e.key === 'Escape') {
199 showSuggestions = false;
200 sugIndex = -1;
201 }
202 }}
203 onblur={() => setTimeout(() => { showSuggestions = false; sugIndex = -1; }, 200)}
204 />
205 {#if showSuggestions}
206 <div class="suggestions">
207 {#each suggestions as s, i}
208 <button type="button" class="sug-btn" class:active={i === sugIndex} onmousedown={() => selectSuggestion(s)}>{s}</button>
209 {/each}
210 </div>
211 {/if}
212 </div>
213 <button class="tag-btn add" onclick={handleAddTags}>ADD</button>
214 <button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button>
215 </div>
216 {#if tagError}
217 <span class="tag-err">{tagError}</span>
218 {/if}
219 </div>
220 {/if}
221
222 <a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
223 DOWNLOAD ORIGINAL
224 </a>
225 </div>
226 </div>
227
228 <p class="back"><a href="/browse">← BACK TO BROWSE</a></p>
229{/if}
230
231<style>
232 .state-msg {
233 padding: 2rem 0;
234 font-size: 1.1rem;
235 color: #888;
236 }
237 .error {
238 color: #c62828;
239 }
240
241 .page-head {
242 margin-bottom: 1.5rem;
243 }
244 .page-head h1 {
245 word-break: break-all;
246 }
247 .head-line {
248 height: 3px;
249 background: #c62828;
250 width: 3rem;
251 margin-top: 0.4rem;
252 }
253
254 .layout {
255 display: flex;
256 gap: 2rem;
257 flex-wrap: wrap;
258 }
259 .preview {
260 flex: 1;
261 min-width: 300px;
262 background: #1a1a1a;
263 display: flex;
264 align-items: center;
265 justify-content: center;
266 }
267 .preview img {
268 display: block;
269 width: 100%;
270 height: auto;
271 max-height: 70vh;
272 object-fit: contain;
273 }
274 .no-thumb {
275 padding: 4rem 2rem;
276 color: #555;
277 font-family: 'Oswald', sans-serif;
278 font-weight: 500;
279 font-size: 0.9rem;
280 letter-spacing: 0.12em;
281 }
282
283 .meta {
284 width: 260px;
285 flex-shrink: 0;
286 display: flex;
287 flex-direction: column;
288 gap: 1rem;
289 }
290 .meta-block {
291 display: flex;
292 flex-direction: column;
293 gap: 0.15rem;
294 }
295 .meta-label {
296 font-family: 'Oswald', sans-serif;
297 font-weight: 500;
298 font-size: 0.7rem;
299 letter-spacing: 0.15em;
300 color: #999;
301 }
302 .meta-value {
303 font-family: 'Inter', sans-serif;
304 font-size: 0.9rem;
305 font-weight: 600;
306 color: #1a1a1a;
307 }
308
309 .uploader-row {
310 display: flex;
311 align-items: center;
312 gap: 0.4rem;
313 }
314 .uploader-link {
315 color: #1a1a1a;
316 text-decoration: none;
317 }
318 .uploader-link:hover {
319 color: #c62828;
320 text-decoration: underline;
321 }
322
323 .avatar-img {
324 width: 1.5rem;
325 height: 1.5rem;
326 object-fit: cover;
327 flex-shrink: 0;
328 }
329
330 .tags-block {
331 flex: 1;
332 }
333 .tag-pills {
334 display: flex;
335 flex-wrap: wrap;
336 gap: 0.35rem;
337 }
338 .tag-pill {
339 display: inline-flex;
340 align-items: center;
341 gap: 0.3rem;
342 background: #1a1a1a;
343 padding: 0.25rem 0.5rem 0.25rem 0.65rem;
344 font-family: 'Inter', sans-serif;
345 font-size: 0.78rem;
346 font-weight: 500;
347 color: #eee;
348 text-decoration: none;
349 transition: background 0.15s;
350 }
351 .tag-pill:hover {
352 background: #c62828;
353 text-decoration: none;
354 }
355 .tag-name {
356 font-weight: 600;
357 }
358 .tag-count {
359 background: rgba(255, 255, 255, 0.12);
360 padding: 0.05rem 0.35rem;
361 font-size: 0.68rem;
362 font-weight: 600;
363 color: rgba(255, 255, 255, 0.65);
364 min-width: 1.1rem;
365 text-align: center;
366 }
367 .tag-pill:hover .tag-count {
368 background: rgba(0, 0, 0, 0.2);
369 }
370 .tag-pill.innate {
371 background: #1565c0;
372 }
373 .tag-pill.innate:hover {
374 background: #1976d2;
375 text-decoration: none;
376 }
377 .tag-pill.innate .tag-count {
378 display: none;
379 }
380 .none {
381 color: #bbb;
382 font-size: 0.85rem;
383 }
384
385 .btn {
386 display: inline-flex;
387 align-items: center;
388 align-self: flex-start;
389 padding: 0.55rem 1.25rem;
390 background: #c62828;
391 color: #fff;
392 border: none;
393 font-family: 'Oswald', sans-serif;
394 font-weight: 500;
395 font-size: 0.8rem;
396 letter-spacing: 0.1em;
397 text-decoration: none;
398 text-transform: uppercase;
399 cursor: pointer;
400 transition: background 0.15s;
401 margin-top: 0.5rem;
402 }
403 .btn:hover {
404 background: #b71c1c;
405 text-decoration: none;
406 }
407
408 .back {
409 margin-top: 2rem;
410 }
411 .back a {
412 font-family: 'Oswald', sans-serif;
413 font-weight: 500;
414 font-size: 0.85rem;
415 letter-spacing: 0.1em;
416 }
417
418 .edit-tags-block {
419 border-top: 2px solid #1a1a1a;
420 padding-top: 0.75rem;
421 }
422 .tag-input-row {
423 display: flex;
424 gap: 0.35rem;
425 align-items: flex-start;
426 }
427 .tag-wrapper {
428 flex: 1;
429 position: relative;
430 min-width: 0;
431 }
432 .tag-wrapper input[type="text"] {
433 width: 100%;
434 padding: 0.4rem 0.5rem;
435 border: 2px solid #1a1a1a;
436 border-radius: 0;
437 font-family: 'Inter', sans-serif;
438 font-size: 0.78rem;
439 font-weight: 500;
440 background: #fff;
441 box-sizing: border-box;
442 }
443 .tag-wrapper input[type="text"]:focus {
444 outline: none;
445 border-color: #c62828;
446 }
447 .suggestions {
448 position: absolute;
449 top: 100%;
450 left: 0;
451 right: 0;
452 background: #fff;
453 border: 2px solid #1a1a1a;
454 border-top: none;
455 margin: 0;
456 padding: 0;
457 z-index: 10;
458 max-height: 130px;
459 overflow-y: auto;
460 }
461 .sug-btn {
462 display: block;
463 width: 100%;
464 text-align: left;
465 padding: 0.35rem 0.5rem;
466 border: none;
467 background: none;
468 font-family: 'Inter', sans-serif;
469 font-size: 0.78rem;
470 font-weight: 500;
471 cursor: pointer;
472 border-radius: 0;
473 }
474 .sug-btn:hover {
475 background: #f0f0f0;
476 }
477 .sug-btn.active {
478 background: #c62828;
479 color: #fff;
480 }
481 .tag-btn {
482 padding: 0.4rem 0.65rem;
483 border: none;
484 font-family: 'Oswald', sans-serif;
485 font-weight: 500;
486 font-size: 0.72rem;
487 letter-spacing: 0.08em;
488 cursor: pointer;
489 white-space: nowrap;
490 transition: background 0.15s;
491 }
492 .tag-btn.add {
493 background: #1a1a1a;
494 color: #fff;
495 }
496 .tag-btn.add:hover {
497 background: #2e7d32;
498 }
499 .tag-btn.remove {
500 background: #1a1a1a;
501 color: #fff;
502 }
503 .tag-btn.remove:hover {
504 background: #c62828;
505 }
506 .tag-err {
507 display: block;
508 margin-top: 0.3rem;
509 color: #c62828;
510 font-family: 'Inter', sans-serif;
511 font-size: 0.72rem;
512 font-weight: 600;
513 }
514</style>